Skip to content

fix(metadata-protocol): the authoring gate resolves references against runtime-authored metadata, not the boot-time registry - #16223

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-15950-runtime-gate-dataset-universe
Sep 6, 2026
Merged

fix(metadata-protocol): the authoring gate resolves references against runtime-authored metadata, not the boot-time registry#16223
os-zhuang merged 2 commits into
mainfrom
claude/issue-15950-runtime-gate-dataset-universe

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #15950

What was measured, before anything was changed

Driving the real write path in one process, no restart between the steps, with
the five code-package datasets present in every read as the firing control:

step reading
saveMetaItem({ type: 'dataset', name: 'p2008_users' }) success, state: 'active', one sys_metadata row
registry.listItems('dataset') [sys_user_metrics, sys_organization_metrics, sys_session_metrics, sys_package_installation_metrics, sys_audit_log_metrics]the authored one absent
getMetaItems({ type: 'dataset' }) the same five plus p2008_users
saveMetaItem({ type: 'dashboard' }), 3 widgets bound to it 422 INVALID_METADATA, three widget-dataset-unknown, hint "Declared datasets: sys_user_metrics, …"

Those middle two lines are the defect in full: two readers of the word "live"
disagreeing about the same artifact in the same instant.

Where the seam actually is

The dispatch brief's candidate was listCollection in
packages/metadata-protocol/src/protocol.ts, marked reasoned-not-measured. It
is confirmed, and the mechanism behind it is now measured rather than
assumed: the gather is per-write and never cached, exactly as its comment says,
but its only source is registry.listItems, and the registry is a boot-time
universe for every metadata type except object.

applyRegistryWriteThrough is why. Its object branch registers
unconditionally; every other type falls to if (this.environmentId !== undefined) return;, and hydrateOverlayIntoRegistry separately declines any org-scoped
row on any kernel. So on the ordinary tenant posture a PUT /meta/dataset that
answers 200 never reaches the registry, and stays invisible to the gate until
a boot re-hydrates it — which is precisely why the card's step 5 (restart,
replay byte for byte) answered 200.

packages/lint is not in breach and is untouched: runtime-gate.ts:165
declares the field as "The live dataset declarations", and the reader that
was wrong is the one that filled it.

The repair

The gather now folds a stored half onto the registry half, in a new private
foldStoredCollection. Four properties, each chosen against a measured
alternative and stated in the method's own docblock:

  • Additive. A stored row contributes a name the registry half does not carry
    and never displaces a registry entry. Not caution for its own sake: an
    object's registry copy is its resolved schema (ADR-0029 D9.2, base plus
    extend contributors) while a sys_metadata row is the base layer alone —
    which is why getMetaItems runs foldObjectExtendersFromRegistry when its own
    merge lets an overlay win. Letting a raw row displace the resolved body would
    trade this card's phantom for a subtler one.
  • Active rows only. A draft must not resolve, or The metadata door accepts a dashboard widget dataset binding that names nothing — 200 on both save and publish, referential integrity enforced only at runtime #7529's refuse-at-publish
    ruling inverts: an author could publish a board satisfied by a dataset that is
    not itself published. Pinned.
  • The write's own partition — env-wide plus, when the write has one, its own
    organization. No other org's overlays are visible to the gate on any kernel.
  • No disabled-package filter, deliberately. getMetaItems applies one; the
    comment on it says in as many words that the registry primitives keep serving
    a disabled package's items so "migrations, cross-package references and the
    runtime authoring gate (protocol.ts resolution context) still see a complete
    object universe". Filtering here would make the stored half narrower than the
    registry half it folds onto.

assertRuntimeAuthoringRules becomes async because a store read is. It is
private, both call sites were already in async functions, and the published
declaration does not move (measured below).

The degradation, which is the half Zone 3 asked about

"Never let context-gathering fail a write" still holds — nothing here throws.
What is not kept is the other half of the old catch {}: degrading into
something that reads like a smaller universe with nothing said. An unprovisioned
sys_metadata is the one benign case (isMissingTableError, this repo's
declared discriminator — the store genuinely holds no rows, so the registry half
is the whole answer). Any other failure is reported once, at warn, naming
the consequence. warn and not error per this repo's degradation rule: no
write claims to have persisted anything it did not; the risk is a wrong verdict
on the next reference. check:durability-log-level is green on the result.

The scope decision Zone 1.4 asked to be made deliberately

The other four arms were measured, not assumed, and the repair is uniform.
listCollection serves five collections through one helper, so this is stated
rather than slipped in:

  • objects — not defective in the common case. Its write-through registers
    unconditionally, so registry and store agree. The fold is a no-op for it
    unless a write-through has failed (best-effort, console.warn) or a row
    arrived from elsewhere, where it is a repair.
  • pages — defective, same shape, lower severity. Measured, not guessed
    (triage asked for this reading and explicitly did not take it): a
    runtime-authored page was invisible to validateViewPageRefs, so a legitimate
    type: 'page' view mount reported view-page-unresolved. That rule reports at
    warning, so the phantom rode in advisories rather than 422-ing the write —
    which is why nobody had filed it. Pinned in both directions in this PR.
  • permissions / books — structurally identical (same helper, same
    write-through early return). Not separately driven end to end; the uniform fold
    covers them and the full suite is green.

A datasets-only special case was rejected on the grounds that it would leave one
helper with two different meanings of "live", for no stated reason.

Cost

Five indexed sys_metadata reads per active publish, issued together, never
on a draft. Bounded by the number of tenant-authored rows of one type —
code-package metadata lives in the registry and never reaches this read. The
comment that used to defend the cheap gather ("a registry map walk plus one
array copy") described only the first half and was the reason the second was
never taken; it now states the real cost and why it is paid.

Verification

All commands run at 7f8fd1b45, exit codes captured before any pipe.

The pin — protocol.runtime-gate-stored-universe.test.ts, 4 tests, green. It
drives saveMetaItem rather than building the gate's arguments by hand, because
the arguments were the wrong thing: runRuntimeAuthoringRules is already pinned
both ways against hand-built context in @objectstack/lint and those pins could
not see this defect and never will.

Ablation — the #7529 shape, 3 → 0. Fix reverted to the merge base
bdc02182b (mutation proven on disk: blob d75be19bd1168cae06, marker
count 3 → 0), pin re-run:

  • publishes a board bound to a dataset saved moments earlierfails,
    422, dashboards[0].widgets[0..2] [widget-dataset-unknown] — three phantoms,
    the card's number.
  • folds the store into EVERY context collectionfails, view-page-unresolved
    present: the pages sibling, confirmed by measurement.
  • still refuses a board bound to a dataset that exists in NEITHER home
    passes both ways, correctly: it is the negative control and is not about
    the fix.

Restored byte-exact — on-disk hash back to the HEAD blob d75be19bd,
git diff HEAD empty, marker count back to 3.

Suites (@objectstack/metadata-protocol and its two most discriminating
consumers, downstream direction):

suite result
@objectstack/metadata-protocol 166 files / 2413 tests passed, 2 files + 10 tests skipped
@objectstack/objectql 274 files / 4724 tests passed
@objectstack/rest 186 files / 3168 tests passed

pnpm --filter @objectstack/metadata-protocol typecheck green, and tsc --listFiles confirms the new test file is in the checked set — the green is
a reading about it rather than a statement about a population that excludes it.

Gates: the family derived from the real diff by scripts/pm/dispatch-gates.mjs
(57 families), all run.
54 green. Three did not produce a reading, and none of
the three is a red:

  • check:dual-build-cjs-loadsexit 3, PREREQUISITE NOT MET: reads built
    output and 57 packages have no dist/ in this worktree. Nothing was measured.
  • check:published-readme-exportsexit 3, same class, same reason.
  • check:react-declaration-parityexit 1, EXTERNAL_INPUT_REQUIRED: its
    right-hand side is objectui's sdui.manifest.json, which AGENTS.md records as
    an on-demand gate triggered by the objectui pin bump, not by CI.

Two gates found real problems in this PR's own new code and both are fixed here:

  • check:objectql-double-limit graded the harness's find double BLIND — it
    ignored the caller's limit and answered with more rows than were asked for,
    which reads exactly like a query that worked. The bound is now applied after
    the filter, by presence.
  • check:engine-double-contract asked for the three seams the new file pins
    (delete, findOne, update) to enter the ledger. Regenerated with
    --write: 3 rows added, 0 lost.

check:dts-closure was re-run after building this package: its first green
swept 13 built packages and this one was not among them, so that green was a
statement about other packages. On the second run it swept 25, and the
package's own build asserts the same thing directly —
check-dts-emitted: @objectstack/metadata-protocol - 2/2 declared declaration file(s) present.

A harness trap worth recording

The stub this harness is modelled on keeps one flat row map and skips
sys_metadata_audit by name. That was invisible for as long as nothing read
sys_metadata as a table — and this change is the first thing that does. A
draft save writes a sys_metadata_history row carrying no state, so the flat
map served it back as an ACTIVE metadata row and a draft-only dataset resolved.
The draft test failed on exactly that before the harness was made table-scoped.
It is the kind of green that would have looked like the product accepting a
draft.

Clause ② — declared yes, with the measurement that argues both halves

Widens the public surface: NO, measured. @objectstack/metadata-protocol
publishes dist, and its entire declaration surface is
dist/index.d.ts + dist/index.d.cts (byte-identical to each other; no chunk
.d.ts exists, so there is no shared-chunk blind spot here). Built at head,
source reverted to the merge base, rebuilt, both files diffed — with the
rebuild proven to have really run by mtimes moving (17886743881788674426
1788674447) and the restored build reproducing the head hash 080dc7fae
exactly:

  • 0 removed lines.
  • 1 added declaration line: private foldStoredCollection; — a private
    member's bare name. Everything else added is JSDoc.
  • private assertRuntimeAuthoringRules; is byte-identical either side: its
    return type moving from the bare RuntimeAuthoringIssue array to a Promise of
    that same array is invisible to a declaration, because TypeScript emits a
    private member as a bare name with no signature.

That added bare-name line is also this instrument's firing positive control:
the diff is not empty, so a byte-identical result elsewhere would have been a
measurement rather than a blind spot. No exported symbol or signature moves.

Changes contract accept/reject behaviour: YES. A PUT /api/v1/meta/dashboard
that answered 422 now answers 200. The lane criterion is "任何改变接受或拒绝
行为的卡,不论多小" — any card that changes accept-or-reject behaviour, however
small — and PUT /api/v1/meta/dashboard is a published contract face; the card
measured it against a --prod-like deployment.

The counter-argument, stated so a reviewer can downgrade this cheaply. The
rejection being removed is a phantom: the lint contract already declares this
collection as the live declarations, so the accepted set of legitimate bodies
is unchanged and the implementation is moving into conformance with a contract
that already said this. That is the declared-≠-enforced class, and it is why
triage and the dispatch brief both read no. This seat declares yes on the
error-cost asymmetry — an over-declaration costs a review round and is visible,
an under-declaration crosses a guardrail invisibly — and because the criterion
is written to defeat "it is only a bug fix". If the reviewer judges the
restoration reading correct, the downgrade is one line.

needs:contract-review is hung on both carriers.

Out of scope, filed rather than fixed

The additive merge leaves one residual, stated in the docblock and filed
separately: where an org overlay redefines a code-package item, the gate
still judges that item's CONTENT from the registry's version, so a board bound
to a measure the overlay removed would be accepted. That is a phantom in the
opposite direction, it needs the same extender-fold reasoning getMetaItems
applies, and it is not this card's.


Generated by Claude Code

…t runtime-authored metadata, not the boot-time registry

`RuntimeStackContext` declares its collections as the LIVE declarations, and
live metadata has two homes: the SchemaRegistry that code packages fill at
boot, and `sys_metadata` that every runtime author writes to. The per-write
gather read only the first, so a `PUT /meta/dataset` that answered 200 was
invisible to the very next `PUT /meta/dashboard`, which refused each widget
bound to it with a phantom `widget-dataset-unknown` until the process restarted.

Measured on the card's shape in one process, no restart: the row is in
`sys_metadata`, `GET /meta/dataset` returns six datasets, the registry returns
the five code-package ones, and the board collects three phantom refusals.

The store half is now folded onto the registry half for every context
collection, additively (a stored row contributes a name the registry lacks and
never displaces a registry entry, whose `object` bodies are the resolved
base-plus-extenders shape a raw row is not), active rows only, in the write's
own organization partition, with a failed read reported rather than degraded
silently into a smaller universe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…limit`, and the ledger records its seams

`check:objectql-double-limit` graded the harness's `find` double BLIND: it
answered every row it matched however small a bound the caller passed, which
reads exactly like a query that worked. The bound is now applied after the
filter and by presence.

`check:engine-double-contract` asked for the three seams this file pins
(`delete`, `findOne`, `update`) to be recorded, so the ledger protects it:
regenerated with `--write`, 3 rows added, 0 lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 9 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f5aec38a6af1679d258c27e13aa9d3e2a723ba11.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f5aec38a6af1679d258c27e13aa9d3e2a723ba11packageMentionDocs.

Which tree this was computed on

This run read content/docs from 56ccf7725d814ec35721a1056794cf9687446b88 — the merge of head 7f8fd1b45b924a70ab7bce38f349633f16acd5c4 into base f5aec38a6af1679d258c27e13aa9d3e2a723ba11, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 56ccf7725d814ec35721a1056794cf9687446b88 && git checkout 56ccf7725d814ec35721a1056794cf9687446b88
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5aec38a6af1679d258c27e13aa9d3e2a723ba11 7f8fd1b45b924a70ab7bce38f349633f16acd5c4 && git checkout -B drift-repro f5aec38a6af1679d258c27e13aa9d3e2a723ba11 && git merge --no-ff 7f8fd1b45b924a70ab7bce38f349633f16acd5c4

node scripts/docs-audit/affected-docs.mjs --json f5aec38a6af1679d258c27e13aa9d3e2a723ba11

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f5aec38a6af1679d258c27e13aa9d3e2a723ba11 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS — PR #16223 at head 7f8fd1b4 (Fixes #15950 · priority:p2)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 07:04Z, on the domain:engine seat's hand-off (5557506251: "not released by this seat at all").

Clause ② — the seat's yes declaration stands as its declaration; what the review measured:

  • Limb 1 (published surface): no exported symbol or signature moves. The seat's own before/after build of dist/index.d.ts + .d.cts shows one added line, private foldStoredCollection;, a private bare name; assertRuntimeAuthoringRules going async is invisible to the declaration. Re-read here on the source: both callers were already async, both now await.
  • Limb 2 (accept/reject): a PUT /api/v1/meta/dashboard that answered 422 with widget-dataset-unknown now answers 200 — but only where the referenced dataset is an active sys_metadata row the read API already served as valid. RuntimeStackContext declares those collections as "the live declarations"; the registry alone was never that. So the rejection removed is a phantom and the accepted set of legitimate bodies is unchanged: the declared-≠-enforced conformance class, not a contract move. The pages sibling (warning-severity, rode in advisories) is the same repair and is pinned both ways. A dataset in neither home is still refused with the same code, status and key path (negative control kept).

Shape reviewed: the fold is additive (a stored row never displaces a registry entry — the registry holds the resolved extend-folded schema, the row holds the base layer); active rows only, so #7529's refuse-at-publish is not inverted by a draft; scoped to env-wide plus the write's own organization, spelled organization_id: oid with null for env-wide exactly as getMetaItems' own two-tier read spells it (protocol.ts :7098); no disabled-package filter, deliberately and for the reason getMetaItems' own comment gives. Degradation: never fails the write; isMissingTableError is the one silent case, anything else is reported once at warn. The five reads run under Promise.all. check:engine-double-contract ledger +3 rows, 0 lost.

Tests read (protocol.runtime-gate-stored-universe.test.ts, 370 lines): drives the real saveMetaItem door, not hand-built gate arguments; the harness models the declared state default and scopes sys_metadata_history so a draft cannot resolve — the trap the round caught in its own first harness is exactly the green that would have accepted a draft.

Changeset: @objectstack/metadata-protocol: patch — correct. CI at 7f8fd1b4: 31 success · 6 skipped · 0 failing. Governed-merge audit on the 4 paths: 0 hits. --pair 16223: exit 0 — the card's claim (5557078940) carries the Claim: / Clause-②: spelling.

Landing by this seat now: needs:contract-review off PR + card #15950 in one stroke ⇒ re-run --pair ⇒ ready-for-review + auto-merge (squash).


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 6, 2026 07:05
@os-zhuang
os-zhuang enabled auto-merge September 6, 2026 07:06
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 618f70d Sep 6, 2026
42 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15950-runtime-gate-dataset-universe branch September 6, 2026 07:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants